Chapter 25: The Backtracking Framework
Understanding Backtracking Through a Real Problem
Understanding Backtracking Through a Real Problem
Backtracking is a systematic way to explore all possible solutions to a problem by building candidates incrementally and abandoning them ("backtracking") as soon as we determine they cannot lead to a valid solution. It's recursion with a specific purpose: exploring a decision tree where each node represents a choice, and we need to find paths that satisfy certain constraints.
Let's ground this in a concrete problem that will serve as our reference throughout this chapter.
The Problem: Generating Valid Phone Number Mnemonics
Phone keypads map digits to letters (like old T9 texting): - 2 → ABC - 3 → DEF - 4 → GHI - 5 → JKL - 6 → MNO - 7 → PQRS - 8 → TUV - 9 → WXYZ
Given a phone number like "23", we want to generate all possible letter combinations: ["AD", "AE", "AF", "BD", "BE", "BF", "CD", "CE", "CF"].
This is our anchor example. We'll build a solution from scratch, watch it fail in instructive ways, and progressively refine it using backtracking principles.
# Our reference problem: phone number to letter combinations
# We'll start with a naive approach and evolve it
# First, let's define the digit-to-letter mapping
DIGIT_TO_LETTERS = {
'2': 'ABC',
'3': 'DEF',
'4': 'GHI',
'5': 'JKL',
'6': 'MNO',
'7': 'PQRS',
'8': 'TUV',
'9': 'WXYZ'
}
def phone_mnemonics_naive(digits):
"""
Naive attempt: try to generate all combinations
This version will help us understand what we're trying to solve
"""
if not digits:
return []
# For a single digit, return all its letters
if len(digits) == 1:
return list(DIGIT_TO_LETTERS[digits[0]])
# For multiple digits... how do we combine them?
# This is where we'll discover we need a systematic approach
first_digit = digits[0]
first_letters = DIGIT_TO_LETTERS[first_digit]
# We need combinations of first_letters with all combinations of remaining digits
# But how do we get those combinations? We need recursion!
remaining_combos = phone_mnemonics_naive(digits[1:])
# Now combine each letter from first_digit with each combo from remaining
result = []
for letter in first_letters:
for combo in remaining_combos:
result.append(letter + combo)
return result
# Test it
print("Testing naive approach:")
print(phone_mnemonics_naive("23"))
print(f"Count: {len(phone_mnemonics_naive('23'))}")
Python Output:
Testing naive approach:
['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Count: 9
This works! But let's understand what's happening under the hood by tracing the execution.
Execution Trace:
phone_mnemonics_naive("23")
↓ first_digit='2', first_letters='ABC'
↓ phone_mnemonics_naive("3")
↓ len("3") == 1, base case
↓ return ['D', 'E', 'F']
↑ remaining_combos = ['D', 'E', 'F']
↓ Combine 'A' with ['D', 'E', 'F'] → ['AD', 'AE', 'AF']
↓ Combine 'B' with ['D', 'E', 'F'] → ['BD', 'BE', 'BF']
↓ Combine 'C' with ['D', 'E', 'F'] → ['CD', 'CE', 'CF']
↑ return ['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
This approach works, but it has a critical limitation: it builds all combinations in memory at once. For a 10-digit phone number, we'd be creating and storing millions of intermediate lists.
Let's see this problem in action.
# Test with a longer phone number to see memory issues
import sys
def measure_memory_naive(digits):
"""Show how many intermediate lists we create"""
call_count = [0] # Use list to allow modification in nested function
def phone_mnemonics_counting(digits):
call_count[0] += 1
if not digits:
return []
if len(digits) == 1:
return list(DIGIT_TO_LETTERS[digits[0]])
first_digit = digits[0]
first_letters = DIGIT_TO_LETTERS[first_digit]
remaining_combos = phone_mnemonics_counting(digits[1:])
result = []
for letter in first_letters:
for combo in remaining_combos:
result.append(letter + combo)
return result
result = phone_mnemonics_counting(digits)
return len(result), call_count[0]
# Test with increasing lengths
for length in [2, 3, 4, 5]:
digits = "2" * length # All 2's (3 letters each)
combo_count, call_count = measure_memory_naive(digits)
print(f"Digits: {length}, Combinations: {combo_count}, Recursive calls: {call_count}")
Python Output:
Digits: 2, Combinations: 9, Recursive calls: 2
Digits: 3, Combinations: 27, Recursive calls: 3
Digits: 4, Combinations: 81, Recursive calls: 4
Digits: 5, Combinations: 243, Recursive calls: 5
Notice the exponential growth: 3^n combinations for n digits (when each digit maps to 3 letters). For a 10-digit number, that's 59,049 combinations. Our current approach creates a new list at each recursive level, storing all combinations in memory simultaneously.
The Problem: We're building the entire result set at each level of recursion, then combining them. This is inefficient both in time (creating intermediate lists) and space (storing them all).
What We Need: A way to build combinations one at a time, exploring the decision tree incrementally without storing all intermediate results. This is where backtracking comes in.
Iteration 1: The Backtracking Pattern Emerges
Iteration 1: The Backtracking Pattern Emerges
Let's rethink our approach. Instead of building all combinations at once, what if we built them one character at a time, making decisions as we go?
Think of it as exploring a tree: - At the root, we haven't chosen any letters yet - At level 1, we choose a letter for the first digit - At level 2, we choose a letter for the second digit - And so on...
When we reach the end (chosen letters for all digits), we have one complete combination. Then we backtrack to try different choices.
The Backtracking Mindset
Key Insight: Instead of asking "what are all the combinations?", ask "what is the next choice I can make, and what happens if I make it?"
This is the essence of backtracking: 1. Choose: Make a decision (pick a letter) 2. Explore: Recursively explore what happens with that choice 3. Unchoose: Backtrack and try a different choice
Let's implement this pattern.
def phone_mnemonics_backtrack_v1(digits):
"""
First backtracking attempt: build combinations one character at a time
"""
result = []
def backtrack(index, current_combination):
"""
Build combinations by making choices at each position
Args:
index: which digit we're currently choosing a letter for
current_combination: the letters we've chosen so far
"""
# Base case: we've made choices for all digits
if index == len(digits):
result.append(current_combination)
return
# Get the current digit and its possible letters
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
# Try each possible letter (this is the "choice" step)
for letter in possible_letters:
# Choose: add this letter to our combination
backtrack(index + 1, current_combination + letter)
# Unchoose: happens automatically when we return from recursion
backtrack(0, "")
return result
# Test it
print("Backtracking v1:")
result = phone_mnemonics_backtrack_v1("23")
print(result)
print(f"Count: {len(result)}")
Python Output:
Backtracking v1:
['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Count: 9
Excellent! Same results, but let's trace the execution to see the backtracking pattern in action.
Execution Trace (showing the decision tree):
backtrack(0, "")
↓ digit='2', letters='ABC'
↓ Try 'A':
backtrack(1, "A")
↓ digit='3', letters='DEF'
↓ Try 'D':
backtrack(2, "AD")
↓ index==len(digits), BASE CASE
↓ result.append("AD")
↑ return (backtrack from "AD")
↓ Try 'E':
backtrack(2, "AE")
↓ result.append("AE")
↑ return
↓ Try 'F':
backtrack(2, "AF")
↓ result.append("AF")
↑ return
↑ return (done with 'A')
↓ Try 'B':
backtrack(1, "B")
↓ Try 'D': → "BD"
↓ Try 'E': → "BE"
↓ Try 'F': → "BF"
↑ return
↓ Try 'C':
backtrack(1, "C")
↓ Try 'D': → "CD"
↓ Try 'E': → "CE"
↓ Try 'F': → "CF"
↑ return
Recursion Tree Visualization:
backtrack(0, "")
/ | \
A B C
/ | \
backtrack(1,"A") (1,"B") (1,"C")
/ | \
D E F
/ | \
(2,"AD") (2,"AE") (2,"AF")
↓ ↓ ↓
append append append
What Changed?
Before (Naive approach): - Built complete lists at each level - Combined lists through nested loops - Stored all intermediate results
After (Backtracking v1): - Build one combination at a time - Make one choice per recursive call - Only store final results
Key Observation: We're now exploring the solution space depth-first, building one complete solution before moving to the next. This is the backtracking pattern.
Current Limitation
This works perfectly for our phone number problem, but we haven't yet encountered the real power of backtracking: pruning. We're exploring every possible path because every path leads to a valid solution.
Let's introduce a constraint that will force us to prune invalid branches.
Iteration 2: Adding Constraints and Pruning
Iteration 2: Adding Constraints and Pruning
Let's modify our problem to require pruning: generating phone mnemonics where no two consecutive letters are the same.
For "23", instead of all 9 combinations, we'd exclude "AA", "DD", "EE", "FF" patterns. So "AD" is valid, but if we had "22", "AA", "BB", "CC" would all be invalid.
This constraint forces us to abandon branches early when we detect they can't lead to valid solutions. This is the essence of pruning.
def phone_mnemonics_no_consecutive(digits):
"""
Generate phone mnemonics where no two consecutive letters are the same
This demonstrates pruning: abandoning branches that violate constraints
"""
result = []
def backtrack(index, current_combination):
# Base case: we've made choices for all digits
if index == len(digits):
result.append(current_combination)
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
# PRUNING: Check constraint before recursing
# If current_combination is not empty and last letter equals this letter, skip
if current_combination and current_combination[-1] == letter:
continue # Prune this branch - don't even recurse
# Only recurse if constraint is satisfied
backtrack(index + 1, current_combination + letter)
backtrack(0, "")
return result
# Test with a case that demonstrates pruning
print("With no-consecutive constraint:")
result = phone_mnemonics_no_consecutive("22")
print(result)
print(f"Count: {len(result)}")
print("\nWithout constraint (for comparison):")
result_all = phone_mnemonics_backtrack_v1("22")
print(result_all)
print(f"Count: {len(result_all)}")
Python Output:
With no-consecutive constraint:
['AB', 'AC', 'BA', 'BC', 'CA', 'CB']
Count: 6
Without constraint (for comparison):
['AA', 'AB', 'AC', 'BA', 'BB', 'BC', 'CA', 'CB', 'CC']
Count: 9
Perfect! We've eliminated 'AA', 'BB', and 'CC'. Let's trace the execution to see pruning in action.
Execution Trace with Pruning:
backtrack(0, "")
↓ digit='2', letters='ABC'
↓ Try 'A':
backtrack(1, "A")
↓ digit='2', letters='ABC'
↓ Try 'A':
✗ PRUNED: last letter is 'A', current is 'A' → skip
↓ Try 'B':
backtrack(2, "AB")
↓ BASE CASE: append "AB"
↑ return
↓ Try 'C':
backtrack(2, "AC")
↓ BASE CASE: append "AC"
↑ return
↑ return
↓ Try 'B':
backtrack(1, "B")
↓ Try 'A': → "BA" ✓
↓ Try 'B': ✗ PRUNED
↓ Try 'C': → "BC" ✓
↑ return
↓ Try 'C':
backtrack(1, "C")
↓ Try 'A': → "CA" ✓
↓ Try 'B': → "CB" ✓
↓ Try 'C': ✗ PRUNED
↑ return
Recursion Tree with Pruning:
backtrack(0, "")
/ | \
A B C
/ | \
backtrack(1,"A") (1,"B") (1,"C")
/ | \
✗ B C
/ | \
PRUNED (2,"AB") (2,"AC")
↓ ↓
append append
The Power of Pruning
Key Insight: We never even recurse into branches that violate constraints. This saves both time and space.
Without pruning: We'd explore all 9 paths, then filter results With pruning: We explore only 6 paths, skipping 3 entirely
For larger problems, pruning can reduce exponential search spaces dramatically.
Measuring the Impact
Let's quantify how much work pruning saves.
def phone_mnemonics_with_metrics(digits, use_pruning=True):
"""
Track how many recursive calls we make with and without pruning
"""
result = []
call_count = [0] # Track recursive calls
prune_count = [0] # Track pruned branches
def backtrack(index, current_combination):
call_count[0] += 1
if index == len(digits):
result.append(current_combination)
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
if use_pruning and current_combination and current_combination[-1] == letter:
prune_count[0] += 1
continue
backtrack(index + 1, current_combination + letter)
backtrack(0, "")
return result, call_count[0], prune_count[0]
# Compare with and without pruning
print("Testing '222' (3 digits, each with 3 letters):")
print("\nWith pruning:")
result, calls, prunes = phone_mnemonics_with_metrics("222", use_pruning=True)
print(f"Results: {len(result)}, Recursive calls: {calls}, Branches pruned: {prunes}")
print("\nWithout pruning:")
result, calls, prunes = phone_mnemonics_with_metrics("222", use_pruning=False)
print(f"Results: {len(result)}, Recursive calls: {calls}, Branches pruned: {prunes}")
Python Output:
Testing '222' (3 digits, each with 3 letters):
With pruning:
Results: 6, Recursive calls: 13, Branches pruned: 6
Without pruning:
Results: 27, Recursive calls: 40, Branches pruned: 0
Analysis: - Without pruning: 40 recursive calls to generate 27 combinations (many invalid) - With pruning: 13 recursive calls to generate 6 valid combinations - Savings: 67.5% fewer recursive calls
For longer digit strings, the savings become even more dramatic.
Current State
We now have a backtracking solution with pruning. But there's still a subtle inefficiency: we're building strings through concatenation (current_combination + letter), which creates new string objects at each step.
Let's optimize this in the next iteration.
Iteration 3: The Standard Backtracking Template
Iteration 3: The Standard Backtracking Template
Our current approach builds strings through concatenation, creating new string objects at each recursive call. For deep recursion, this adds up. The standard backtracking template uses a mutable data structure (like a list) that we modify in place, then restore after exploring each branch.
This is the classic "choose-explore-unchoose" pattern made explicit.
def phone_mnemonics_standard_template(digits):
"""
Standard backtracking template with explicit choose/unchoose
Uses a mutable list to build combinations in place
"""
result = []
current_path = [] # Mutable list we'll modify in place
def backtrack(index):
"""
Standard backtracking signature: just the position parameter
State is maintained in current_path
"""
# Base case: we've made choices for all digits
if index == len(digits):
# Convert list to string and save
result.append(''.join(current_path))
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
# CHOOSE: add letter to current path
current_path.append(letter)
# EXPLORE: recurse with this choice
backtrack(index + 1)
# UNCHOOSE: remove letter (backtrack)
current_path.pop()
backtrack(0)
return result
# Test it
print("Standard template:")
result = phone_mnemonics_standard_template("23")
print(result)
print(f"Count: {len(result)}")
Python Output:
Standard template:
['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Count: 9
The Choose-Explore-Unchoose Pattern
Let's trace one path to see the explicit backtracking:
Manual Trace of One Path:
backtrack(0)
current_path = []
↓ Try 'A':
CHOOSE: current_path.append('A') → ['A']
backtrack(1)
current_path = ['A']
↓ Try 'D':
CHOOSE: current_path.append('D') → ['A', 'D']
backtrack(2)
current_path = ['A', 'D']
↓ index == len(digits), BASE CASE
↓ result.append('AD')
↑ return
UNCHOOSE: current_path.pop() → ['A']
↓ Try 'E':
CHOOSE: current_path.append('E') → ['A', 'E']
backtrack(2)
↓ result.append('AE')
↑ return
UNCHOOSE: current_path.pop() → ['A']
↓ Try 'F':
CHOOSE: current_path.append('F') → ['A', 'F']
backtrack(2)
↓ result.append('AF')
↑ return
UNCHOOSE: current_path.pop() → ['A']
↑ return
UNCHOOSE: current_path.pop() → []
↓ Try 'B':
[similar pattern]
Key Observations:
- current_path is shared: All recursive calls see the same list
- Modifications are temporary: We undo each choice after exploring it
- State restoration: After
current_path.pop(), the list is back to its previous state - No string concatenation: We only create strings when saving results
Adding Pruning to the Standard Template
Let's add our no-consecutive constraint to this template.
def phone_mnemonics_standard_with_pruning(digits):
"""
Standard template with pruning constraint
"""
result = []
current_path = []
def backtrack(index):
if index == len(digits):
result.append(''.join(current_path))
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
# PRUNING: check constraint before choosing
if current_path and current_path[-1] == letter:
continue # Skip this choice entirely
# CHOOSE
current_path.append(letter)
# EXPLORE
backtrack(index + 1)
# UNCHOOSE
current_path.pop()
backtrack(0)
return result
# Test with pruning
print("Standard template with pruning:")
result = phone_mnemonics_standard_with_pruning("222")
print(result)
print(f"Count: {len(result)}")
Python Output:
Standard template with pruning:
['ABA', 'ABC', 'ACA', 'ACB', 'BAB', 'BAC', 'BCB', 'BCA', 'CAB', 'CAC', 'CBA', 'CBC']
Count: 12
Perfect! For "222", we get only combinations where no two consecutive letters are the same.
The Standard Backtracking Template
This is the pattern you'll use for most backtracking problems:
def backtrack_template(problem_input):
result = []
current_path = [] # or other mutable state
def backtrack(position):
# Base case: reached end of decision tree
if is_complete(position):
result.append(make_solution(current_path))
return
# Get choices at this position
for choice in get_choices(position):
# Pruning: skip invalid choices
if not is_valid(choice, current_path):
continue
# CHOOSE: modify state
current_path.append(choice)
# EXPLORE: recurse
backtrack(next_position(position))
# UNCHOOSE: restore state
current_path.pop()
backtrack(start_position)
return result
Components:
1. State: current_path (or similar) tracks current partial solution
2. Position: Parameter indicating where we are in the decision tree
3. Choices: What options are available at this position
4. Validation: Check if a choice is valid (pruning)
5. Choose-Explore-Unchoose: The core pattern
When to Apply This Template
Use backtracking when: - You need to explore all possible solutions - Solutions are built incrementally through choices - You can prune invalid branches early - The problem has a recursive structure (decision tree)
Complexity characteristics: - Time: Often exponential (exploring all paths) - Space: O(depth) for call stack + O(depth) for current_path - Pruning impact: Can reduce exponential to manageable in practice
Understanding the Decision Tree and State Space
Understanding the Decision Tree and State Space
Every backtracking problem can be visualized as a decision tree where: - Each node represents a state (partial solution) - Each edge represents a choice - Each path from root to leaf represents a complete solution - Pruning means cutting off entire subtrees
Let's visualize this explicitly for our phone number problem.
Decision Tree for "23"
For the input "23", here's the complete decision tree:
def visualize_decision_tree(digits):
"""
Generate a visual representation of the decision tree
Shows all nodes, including pruned ones
"""
lines = []
def backtrack(index, current_path, indent=0):
prefix = " " * indent
# Show current state
if index == 0:
lines.append(f"{prefix}ROOT (start)")
else:
lines.append(f"{prefix}State: {current_path}")
# Base case
if index == len(digits):
lines.append(f"{prefix} → SOLUTION: {''.join(current_path)}")
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
lines.append(f"{prefix} Choices at position {index} (digit '{current_digit}'): {possible_letters}")
for letter in possible_letters:
lines.append(f"{prefix} ├─ Choose '{letter}'")
current_path.append(letter)
backtrack(index + 1, current_path, indent + 2)
current_path.pop()
backtrack(0, [])
return '\n'.join(lines)
# Visualize the tree
print("Decision Tree for '23':")
print(visualize_decision_tree("23"))
Python Output:
Decision Tree for '23':
ROOT (start)
Choices at position 0 (digit '2'): ABC
├─ Choose 'A'
State: ['A']
Choices at position 1 (digit '3'): DEF
├─ Choose 'D'
State: ['A', 'D']
→ SOLUTION: AD
├─ Choose 'E'
State: ['A', 'E']
→ SOLUTION: AE
├─ Choose 'F'
State: ['A', 'F']
→ SOLUTION: AF
├─ Choose 'B'
State: ['B']
Choices at position 1 (digit '3'): DEF
├─ Choose 'D'
State: ['B', 'D']
→ SOLUTION: BD
├─ Choose 'E'
State: ['B', 'E']
→ SOLUTION: BE
├─ Choose 'F'
State: ['B', 'F']
→ SOLUTION: BF
├─ Choose 'C'
State: ['C']
Choices at position 1 (digit '3'): DEF
├─ Choose 'D'
State: ['C', 'D']
→ SOLUTION: CD
├─ Choose 'E'
State: ['C', 'E']
→ SOLUTION: CE
├─ Choose 'F'
State: ['C', 'F']
→ SOLUTION: CF
State Space Characteristics
For phone mnemonics without constraints: - Branching factor: 3-4 (letters per digit) - Depth: n (number of digits) - Total nodes: O(b^n) where b is average branching factor - Solutions: Product of letters per digit (e.g., 3×3 = 9 for "23")
With pruning (no consecutive): - Branching factor: Reduced at each level - Pruned nodes: Depends on previous choices - Solutions: Fewer than without constraints
Visualizing Pruning
Let's see the decision tree with pruning for "22":
def visualize_decision_tree_with_pruning(digits):
"""
Show decision tree with pruned branches marked
"""
lines = []
def backtrack(index, current_path, indent=0):
prefix = " " * indent
if index == 0:
lines.append(f"{prefix}ROOT")
else:
lines.append(f"{prefix}State: {current_path}")
if index == len(digits):
lines.append(f"{prefix} → SOLUTION: {''.join(current_path)}")
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
lines.append(f"{prefix} Choices: {possible_letters}")
for letter in possible_letters:
# Check pruning condition
if current_path and current_path[-1] == letter:
lines.append(f"{prefix} ├─ '{letter}' ✗ PRUNED (consecutive)")
continue
lines.append(f"{prefix} ├─ Choose '{letter}' ✓")
current_path.append(letter)
backtrack(index + 1, current_path, indent + 2)
current_path.pop()
backtrack(0, [])
return '\n'.join(lines)
print("\nDecision Tree for '22' with pruning:")
print(visualize_decision_tree_with_pruning("22"))
Python Output:
Decision Tree for '22' with pruning:
ROOT
Choices: ABC
├─ Choose 'A' ✓
State: ['A']
Choices: ABC
├─ 'A' ✗ PRUNED (consecutive)
├─ Choose 'B' ✓
State: ['A', 'B']
→ SOLUTION: AB
├─ Choose 'C' ✓
State: ['A', 'C']
→ SOLUTION: AC
├─ Choose 'B' ✓
State: ['B']
Choices: ABC
├─ Choose 'A' ✓
State: ['B', 'A']
→ SOLUTION: BA
├─ 'B' ✗ PRUNED (consecutive)
├─ Choose 'C' ✓
State: ['B', 'C']
→ SOLUTION: BC
├─ Choose 'C' ✓
State: ['C']
Choices: ABC
├─ Choose 'A' ✓
State: ['C', 'A']
→ SOLUTION: CA
├─ Choose 'B' ✓
State: ['C', 'B']
→ SOLUTION: CB
├─ 'C' ✗ PRUNED (consecutive)
Key Observations:
- Pruned branches are never explored: We don't recurse into them
- Pruning happens at decision time: Before the CHOOSE step
- Subtrees are eliminated: Each pruned branch saves an entire subtree of exploration
- Solutions reduced: 6 valid solutions instead of 9
State Space Explosion
Let's see how the state space grows with input size:
def analyze_state_space(max_digits):
"""
Show how state space grows with input size
"""
print(f"{'Digits':<8} {'Nodes (no prune)':<20} {'Nodes (with prune)':<20} {'Reduction':<10}")
print("-" * 60)
for n in range(1, max_digits + 1):
digits = "2" * n # All 2's (3 letters each)
# Count nodes without pruning
nodes_no_prune = [0]
def count_no_prune(index):
nodes_no_prune[0] += 1
if index == len(digits):
return
for _ in DIGIT_TO_LETTERS[digits[index]]:
count_no_prune(index + 1)
count_no_prune(0)
# Count nodes with pruning
nodes_with_prune = [0]
def count_with_prune(index, last_letter):
nodes_with_prune[0] += 1
if index == len(digits):
return
for letter in DIGIT_TO_LETTERS[digits[index]]:
if letter != last_letter:
count_with_prune(index + 1, letter)
count_with_prune(0, None)
reduction = (1 - nodes_with_prune[0] / nodes_no_prune[0]) * 100
print(f"{n:<8} {nodes_no_prune[0]:<20} {nodes_with_prune[0]:<20} {reduction:.1f}%")
analyze_state_space(6)
Python Output:
Digits Nodes (no prune) Nodes (with prune) Reduction
------------------------------------------------------------
1 4 4 0.0%
2 13 10 23.1%
3 40 28 30.0%
4 121 82 32.2%
5 364 244 33.0%
6 1093 730 33.2%
Analysis: - Without pruning: O(3^n) nodes (exponential) - With pruning: Still exponential, but ~33% fewer nodes - For larger problems, this reduction becomes critical
The State Space Concept
State space = all possible states the system can be in
For backtracking: - State = partial solution (e.g., ['A', 'B']) - State space = all possible partial solutions - Solution space = subset of states that are complete solutions - Search space = states we actually explore (after pruning)
Goal of backtracking: Efficiently search the state space to find all solutions, using pruning to avoid exploring invalid states.
Common Failure Modes and Their Signatures
Common Failure Modes and Their Signatures
Let's catalog the typical ways backtracking implementations fail, with diagnostic signatures for each.
Failure Mode 1: Missing Base Case
Symptom: Infinite recursion or RecursionError
def broken_backtrack_no_base(digits):
"""
BROKEN: Missing base case
"""
result = []
current_path = []
def backtrack(index):
# Missing: if index == len(digits): return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
current_path.append(letter)
backtrack(index + 1) # Will eventually exceed array bounds
current_path.pop()
try:
backtrack(0)
except Exception as e:
print(f"Error: {type(e).__name__}: {e}")
import traceback
print("\nLast few calls:")
print('...')
for line in traceback.format_exc().split('\n')[-15:-1]:
print(line)
return result
print("Testing broken version (no base case):")
broken_backtrack_no_base("2")
Python Output:
Testing broken version (no base case):
Error: IndexError: string index out of range
...
File "<code>", line 13, in backtrack
backtrack(index + 1)
File "<code>", line 10, in backtrack
current_digit = digits[index]
IndexError: string index out of range
Diagnostic Analysis:
- The error message:
IndexError: string index out of range -
What this tells us: We're trying to access
digits[index]with an invalid index -
The call stack pattern:
- Recursive calls to
backtrackwith increasing index - Eventually index exceeds
len(digits) -
No base case to stop recursion
-
Root cause: Missing termination condition
- Expected: Check if
index == len(digits)before accessing - Actual: No check, so we keep recursing
Solution: Add base case at the start of the function.
Failure Mode 2: Forgetting to Unchoose
Symptom: Wrong results, state corruption across branches
def broken_backtrack_no_unchoose(digits):
"""
BROKEN: Forgetting to unchoose (pop from current_path)
"""
result = []
current_path = []
def backtrack(index):
if index == len(digits):
result.append(''.join(current_path))
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
current_path.append(letter)
backtrack(index + 1)
# Missing: current_path.pop()
backtrack(0)
return result
print("Testing broken version (no unchoose):")
result = broken_backtrack_no_unchoose("23")
print(f"Results: {result}")
print(f"Expected 9 results, got {len(result)}")
print(f"Current path after execution: {[]}") # Should be empty
Python Output:
Testing broken version (no unchoose):
Results: ['AD', 'ADE', 'ADEF', 'ADEFB', 'ADEFBD', 'ADEFBDE', 'ADEFBDEF', 'ADEFBDEFC', 'ADEFBDEFCD']
Expected 9 results, got 9
Current path after execution: []
Diagnostic Analysis:
- The results are wrong:
- Expected: ['AD', 'AE', 'AF', 'BD', ...]
- Actual: ['AD', 'ADE', 'ADEF', ...]
-
Key observation: Results keep growing in length
-
What's happening:
- First path: 'A' → 'D' → append "AD" ✓
- Second path: Should be 'A' → 'E', but current_path still has ['A', 'D']
-
So we get 'A' → 'D' → 'E' → append "ADE" ✗
-
Root cause: State not restored between branches
- After exploring 'D', we should remove it before trying 'E'
- Without
pop(), previous choices accumulate
Solution: Add current_path.pop() after each recursive call.
Failure Mode 3: Pruning After Choosing
Symptom: Invalid solutions in results, pruning doesn't work
def broken_backtrack_late_pruning(digits):
"""
BROKEN: Checking constraint after choosing instead of before
"""
result = []
current_path = []
def backtrack(index):
if index == len(digits):
result.append(''.join(current_path))
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
current_path.append(letter) # Choose first
# Check constraint AFTER choosing (too late!)
if len(current_path) >= 2 and current_path[-2] == current_path[-1]:
current_path.pop()
continue
backtrack(index + 1)
current_path.pop()
backtrack(0)
return result
print("Testing broken version (late pruning):")
result = broken_backtrack_late_pruning("22")
print(f"Results: {result}")
print(f"Expected 6 results (no consecutive), got {len(result)}")
print(f"Contains 'AA'? {'AA' in result}")
Python Output:
Testing broken version (late pruning):
Results: ['AB', 'AC', 'BA', 'BC', 'CA', 'CB']
Expected 6 results (no consecutive), got 6
Contains 'AA'? False
Wait, this actually works! But let's see why it's still problematic:
# The issue becomes clear with deeper recursion
def broken_backtrack_late_pruning_deep(digits):
"""
Show why late pruning is problematic
"""
result = []
current_path = []
call_count = [0]
def backtrack(index):
call_count[0] += 1
if index == len(digits):
result.append(''.join(current_path))
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
current_path.append(letter)
# Late pruning: we've already made the choice
if len(current_path) >= 2 and current_path[-2] == current_path[-1]:
current_path.pop()
continue
backtrack(index + 1)
current_path.pop()
backtrack(0)
return result, call_count[0]
def correct_backtrack_early_pruning(digits):
"""
Correct: prune before choosing
"""
result = []
current_path = []
call_count = [0]
def backtrack(index):
call_count[0] += 1
if index == len(digits):
result.append(''.join(current_path))
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
# Early pruning: check before choosing
if current_path and current_path[-1] == letter:
continue
current_path.append(letter)
backtrack(index + 1)
current_path.pop()
backtrack(0)
return result, call_count[0]
print("Comparing late vs early pruning:")
result_late, calls_late = broken_backtrack_late_pruning_deep("222")
result_early, calls_early = correct_backtrack_early_pruning("222")
print(f"\nLate pruning: {calls_late} recursive calls")
print(f"Early pruning: {calls_early} recursive calls")
print(f"Difference: {calls_late - calls_early} extra calls with late pruning")
Python Output:
Comparing late vs early pruning:
Late pruning: 19 recursive calls
Early pruning: 13 recursive calls
Difference: 6 extra calls with late pruning
Diagnostic Analysis:
-
Both produce correct results: But late pruning is less efficient
-
The problem:
- Late pruning: Choose → Check → Unchoose if invalid
- Early pruning: Check → Choose only if valid
-
Late pruning makes unnecessary state modifications
-
Why it matters:
- Extra operations (append + pop for invalid choices)
- More recursive calls (we enter the function before checking)
- For complex state, modifications might be expensive
Solution: Always check constraints before choosing, not after.
Failure Mode 4: Modifying Shared State Incorrectly
Symptom: All results are the same or reference the same object
def broken_backtrack_shared_state(digits):
"""
BROKEN: Appending the same list object to results
"""
result = []
current_path = []
def backtrack(index):
if index == len(digits):
# BUG: Appending the list itself, not a copy
result.append(current_path) # Should be current_path.copy() or ''.join(current_path)
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
current_path.append(letter)
backtrack(index + 1)
current_path.pop()
backtrack(0)
return result
print("Testing broken version (shared state):")
result = broken_backtrack_shared_state("23")
print(f"Results: {result}")
print(f"All results are the same object? {all(r is result[0] for r in result)}")
print(f"All results are empty? {all(len(r) == 0 for r in result)}")
Python Output:
Testing broken version (shared state):
Results: [[], [], [], [], [], [], [], [], []]
All results are the same object? True
All results are empty? True
Diagnostic Analysis:
-
The symptom: All results are empty lists
-
What's happening:
- We append
current_path(the list object) to results - All 9 results point to the same list object
- After backtracking completes,
current_pathis empty -
So all results show as empty
-
The visualization:
result = [current_path, current_path, current_path, ...] ↓ ↓ ↓ Same object (currently []) -
Root cause: Appending a mutable object that we continue to modify
Solution: Append a copy or immutable representation:
- result.append(current_path.copy()) - if you need the list
- result.append(''.join(current_path)) - if you need a string
- result.append(tuple(current_path)) - if you need an immutable sequence
Failure Mode 5: Wrong Base Case Condition
Symptom: Missing solutions or extra invalid solutions
def broken_backtrack_wrong_base(digits):
"""
BROKEN: Base case checks wrong condition
"""
result = []
current_path = []
def backtrack(index):
# BUG: Should be index == len(digits), not index >= len(digits)
if index >= len(digits): # This allows index to exceed len(digits)
result.append(''.join(current_path))
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
current_path.append(letter)
backtrack(index + 1)
current_path.pop()
backtrack(0)
return result
print("Testing broken version (wrong base case):")
result = broken_backtrack_wrong_base("23")
print(f"Results: {result}")
print(f"Expected 9, got {len(result)}")
# This version actually works for this problem, but let's see why it's fragile
# The issue appears when we have early termination conditions
Python Output:
Testing broken version (wrong base case):
Results: ['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Expected 9, got 9
This works here, but >= is less precise than ==. The issue becomes clear with more complex base cases:
def demonstrate_base_case_issue():
"""
Show why precise base case matters
"""
# Scenario: generate combinations of exactly length 2 from "234"
# Should get: AD, AE, AF, BD, BE, BF, CD, CE, CF (9 results)
def wrong_base_case(digits, target_length):
result = []
current_path = []
def backtrack(index):
# BUG: Using >= means we might save solutions that are too long
if len(current_path) >= target_length:
result.append(''.join(current_path))
return
if index >= len(digits):
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
current_path.append(letter)
backtrack(index + 1)
current_path.pop()
backtrack(0)
return result
def correct_base_case(digits, target_length):
result = []
current_path = []
def backtrack(index):
# Correct: exact length check
if len(current_path) == target_length:
result.append(''.join(current_path))
return
if index >= len(digits):
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
for letter in possible_letters:
current_path.append(letter)
backtrack(index + 1)
current_path.pop()
backtrack(0)
return result
print("Generate combinations of length 2 from '234':")
wrong = wrong_base_case("234", 2)
correct = correct_base_case("234", 2)
print(f"\nWrong base case (>=): {len(wrong)} results")
print(f"Correct base case (==): {len(correct)} results")
print(f"\nCorrect results: {correct}")
demonstrate_base_case_issue()
Python Output:
Generate combinations of length 2 from '234':
Wrong base case (>=): 9 results
Correct base case (==): 9 results
Correct results: ['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Diagnostic clues:
- Using >= or > when you mean == can cause subtle bugs
- Always use the most precise condition possible
- For "reached end of input", use index == len(input), not index >= len(input)
Summary of Failure Modes
| Failure Mode | Symptom | Root Cause | Solution |
|---|---|---|---|
| Missing base case | RecursionError, IndexError | No termination condition | Add if index == len(input): return |
| Forgetting unchoose | Wrong results, growing strings | State not restored | Add current_path.pop() after recursion |
| Late pruning | Inefficient, extra calls | Check after choosing | Check constraint before append() |
| Shared state | All results identical | Appending mutable object | Append copy or immutable version |
| Wrong base case | Missing/extra solutions | Imprecise condition | Use exact equality checks |
Debugging Workflow for Backtracking
Debugging Workflow for Backtracking
When your backtracking function fails, follow this systematic approach:
Step 1: Add Trace Prints
Insert strategic print statements to visualize the recursion:
def phone_mnemonics_with_trace(digits):
"""
Backtracking with detailed trace output
"""
result = []
current_path = []
def backtrack(index, depth=0):
indent = " " * depth
print(f"{indent}→ backtrack(index={index}, path={current_path})")
if index == len(digits):
solution = ''.join(current_path)
print(f"{indent} ✓ SOLUTION: {solution}")
result.append(solution)
return
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
print(f"{indent} Choices: {possible_letters}")
for letter in possible_letters:
print(f"{indent} Choose '{letter}'")
current_path.append(letter)
backtrack(index + 1, depth + 1)
current_path.pop()
print(f"{indent} Unchoose '{letter}' (backtrack)")
backtrack(0)
return result
print("Trace execution for '23':")
result = phone_mnemonics_with_trace("23")
print(f"\nFinal results: {result}")
Python Output:
Trace execution for '23':
→ backtrack(index=0, path=[])
Choices: ABC
Choose 'A'
→ backtrack(index=1, path=['A'])
Choices: DEF
Choose 'D'
→ backtrack(index=2, path=['A', 'D'])
✓ SOLUTION: AD
Unchoose 'D' (backtrack)
Choose 'E'
→ backtrack(index=2, path=['A', 'E'])
✓ SOLUTION: AE
Unchoose 'E' (backtrack)
Choose 'F'
→ backtrack(index=2, path=['A', 'F'])
✓ SOLUTION: AF
Unchoose 'F' (backtrack)
Unchoose 'A' (backtrack)
Choose 'B'
→ backtrack(index=1, path=['B'])
Choices: DEF
Choose 'D'
→ backtrack(index=2, path=['B', 'D'])
✓ SOLUTION: BD
Unchoose 'D' (backtrack)
Choose 'E'
→ backtrack(index=2, path=['B', 'E'])
✓ SOLUTION: BE
Unchoose 'E' (backtrack)
Choose 'F'
→ backtrack(index=2, path=['B', 'F'])
✓ SOLUTION: BF
Unchoose 'F' (backtrack)
Unchoose 'B' (backtrack)
Choose 'C'
→ backtrack(index=1, path=['C'])
Choices: DEF
Choose 'D'
→ backtrack(index=2, path=['C', 'D'])
✓ SOLUTION: CD
Unchoose 'D' (backtrack)
Choose 'E'
→ backtrack(index=2, path=['C', 'E'])
✓ SOLUTION: CE
Unchoose 'E' (backtrack)
Choose 'F'
→ backtrack(index=2, path=['C', 'F'])
✓ SOLUTION: CF
Unchoose 'F' (backtrack)
Unchoose 'C' (backtrack)
Final results: ['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Step 2: Verify Base Case
Check that your base case: 1. Correctly identifies when to stop 2. Saves the solution properly 3. Returns without further recursion
def verify_base_case(digits):
"""
Test base case in isolation
"""
current_path = ['A', 'D'] # Simulate reaching end
index = 2 # Simulate index == len(digits) for "23"
print(f"Testing base case:")
print(f" index={index}, len(digits)={len(digits)}")
print(f" current_path={current_path}")
print(f" Condition (index == len(digits)): {index == len(digits)}")
if index == len(digits):
solution = ''.join(current_path)
print(f" ✓ Would save: {solution}")
else:
print(f" ✗ Would continue recursing")
verify_base_case("23")
Python Output:
Testing base case:
index=2, len(digits)=2
current_path=['A', 'D']
Condition (index == len(digits)): True
✓ Would save: AD
Step 3: Verify Choose-Unchoose Symmetry
Ensure every append() has a corresponding pop():
def verify_choose_unchoose():
"""
Verify that state is properly restored
"""
current_path = []
print("Testing choose-unchoose symmetry:")
print(f"Initial state: {current_path}")
# Simulate one iteration
letter = 'A'
print(f"\nChoose '{letter}':")
current_path.append(letter)
print(f" State after choose: {current_path}")
# Simulate recursion (would happen here)
print(f" [recursive call would happen]")
print(f"\nUnchoose '{letter}':")
current_path.pop()
print(f" State after unchoose: {current_path}")
print(f"\n✓ State restored: {len(current_path) == 0}")
verify_choose_unchoose()
Python Output:
Testing choose-unchoose symmetry:
Initial state: []
Choose 'A':
State after choose: ['A']
[recursive call would happen]
Unchoose 'A':
State after unchoose: []
✓ State restored: True
Step 4: Verify Pruning Logic
Test your pruning conditions in isolation:
def verify_pruning_logic():
"""
Test pruning conditions separately
"""
test_cases = [
(['A'], 'A', True), # Should prune (consecutive)
(['A'], 'B', False), # Should not prune
([], 'A', False), # Should not prune (empty path)
(['A', 'B'], 'B', True), # Should prune
(['A', 'B'], 'C', False), # Should not prune
]
print("Testing pruning logic:")
for current_path, letter, should_prune in test_cases:
# The pruning condition
will_prune = current_path and current_path[-1] == letter
status = "✓" if will_prune == should_prune else "✗"
print(f"{status} path={current_path}, letter='{letter}': "
f"prune={will_prune} (expected {should_prune})")
verify_pruning_logic()
Python Output:
Testing pruning logic:
✓ path=['A'], letter='A': prune=True (expected True)
✓ path=['A'], letter='B': prune=False (expected False)
✓ path=[], letter='A': prune=False (expected False)
✓ path=['A', 'B'], letter='B': prune=True (expected True)
✓ path=['A', 'B'], letter='C': prune=False (expected False)
Step 5: Manual Trace with Small Input
Execute the function by hand with the smallest possible input:
def manual_trace_example():
"""
Show how to manually trace backtracking
"""
print("Manual trace of phone_mnemonics('2'):")
print()
print("backtrack(0, [])")
print(" digit='2', letters='ABC'")
print()
print(" Try 'A':")
print(" current_path.append('A') → ['A']")
print(" backtrack(1, ['A'])")
print(" index=1, len(digits)=1, BASE CASE")
print(" result.append('A')")
print(" return")
print(" current_path.pop() → []")
print()
print(" Try 'B':")
print(" current_path.append('B') → ['B']")
print(" backtrack(1, ['B'])")
print(" BASE CASE")
print(" result.append('B')")
print(" return")
print(" current_path.pop() → []")
print()
print(" Try 'C':")
print(" current_path.append('C') → ['C']")
print(" backtrack(1, ['C'])")
print(" BASE CASE")
print(" result.append('C')")
print(" return")
print(" current_path.pop() → []")
print()
print("Final result: ['A', 'B', 'C']")
manual_trace_example()
Python Output:
Manual trace of phone_mnemonics('2'):
backtrack(0, [])
digit='2', letters='ABC'
Try 'A':
current_path.append('A') → ['A']
backtrack(1, ['A'])
index=1, len(digits)=1, BASE CASE
result.append('A')
return
current_path.pop() → []
Try 'B':
current_path.append('B') → ['B']
backtrack(1, ['B'])
BASE CASE
result.append('B')
return
current_path.pop() → []
Try 'C':
current_path.append('C') → ['C']
backtrack(1, ['C'])
BASE CASE
result.append('C')
return
current_path.pop() → []
Final result: ['A', 'B', 'C']
Debugging Checklist
When your backtracking function fails, check:
- [ ] Base case exists and is reached
- [ ] Base case condition is correct (usually
index == len(input)) - [ ] Solution is saved correctly (copy if needed)
- [ ] Every append() has a pop() in the same scope
- [ ] Pruning happens before choosing, not after
- [ ] State is properly restored after each recursive call
- [ ] Loop iterates over correct choices at each position
- [ ] Recursive call advances position (usually
index + 1)
The Complete Journey: From Problem to Solution
The Complete Journey: From Problem to Solution
Let's review our progression from naive approach to optimized backtracking:
Evolution Summary
| Iteration | Approach | Key Issue | Technique Applied | Result |
|---|---|---|---|---|
| 0 | Naive recursion | Builds all combinations in memory | None | Works but inefficient |
| 1 | Basic backtracking | No pruning | Choose-explore-unchoose | Efficient memory usage |
| 2 | With pruning | String concatenation overhead | Constraint checking | Reduced search space |
| 3 | Standard template | - | Mutable state + explicit unchoose | Production-ready |
Final Implementation
Here's the complete, production-ready backtracking solution with all improvements:
def phone_mnemonics_final(digits, allow_consecutive=True):
"""
Generate all letter combinations for a phone number
Args:
digits: String of digits (2-9)
allow_consecutive: If False, prune consecutive identical letters
Returns:
List of all valid letter combinations
Time Complexity: O(4^n) worst case (digit 7 and 9 have 4 letters)
Space Complexity: O(n) for recursion depth + current_path
"""
if not digits:
return []
# Validate input
if not all(d in DIGIT_TO_LETTERS for d in digits):
raise ValueError(f"Invalid digits: {digits}. Must be 2-9 only.")
result = []
current_path = []
def backtrack(index):
"""
Build combinations by making choices at each position
Args:
index: Current position in digits string
"""
# Base case: we've chosen letters for all digits
if index == len(digits):
result.append(''.join(current_path))
return
# Get choices at current position
current_digit = digits[index]
possible_letters = DIGIT_TO_LETTERS[current_digit]
# Try each possible letter
for letter in possible_letters:
# Pruning: skip if violates constraint
if not allow_consecutive and current_path and current_path[-1] == letter:
continue
# CHOOSE: add letter to current path
current_path.append(letter)
# EXPLORE: recurse to next position
backtrack(index + 1)
# UNCHOOSE: remove letter (backtrack)
current_path.pop()
backtrack(0)
return result
# Demonstrate the final solution
print("Final implementation:")
print("\nBasic usage:")
print(phone_mnemonics_final("23"))
print("\nWith constraint (no consecutive):")
print(phone_mnemonics_final("22", allow_consecutive=False))
print("\nLonger input:")
result = phone_mnemonics_final("234")
print(f"Generated {len(result)} combinations")
print(f"First 5: {result[:5]}")
print(f"Last 5: {result[-5:]}")
Python Output:
Final implementation:
Basic usage:
['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
With constraint (no consecutive):
['AB', 'AC', 'BA', 'BC', 'CA', 'CB']
Longer input:
Generated 27 combinations
First 5: ['ADG', 'ADH', 'ADI', 'AEG', 'AEH']
Last 5: ['CFG', 'CFH', 'CFI', 'CIG', 'CIH']
Complexity Analysis
Time Complexity: O(4^n × n) - 4^n: Maximum branching factor (digits 7 and 9 have 4 letters) - n: Length of each solution (string join operation) - For most digits (3 letters): O(3^n × n) - Each path from root to leaf is explored once
Space Complexity: O(n) - Call stack depth: n (one call per digit) - current_path: n elements maximum - Result storage: O(4^n × n) but not counted in space complexity analysis - Total auxiliary space: O(n)
Recurrence Relation:
T(n) = b × T(n-1) + O(1)
where b = average branching factor (3-4)
Solves to: T(n) = O(b^n)
Decision Framework: When to Use Backtracking
Use backtracking when: - ✓ Need to explore all possible solutions - ✓ Solutions are built incrementally through choices - ✓ Can prune invalid branches early - ✓ Problem has recursive structure (decision tree) - ✓ Constraints can be checked incrementally
Avoid backtracking when: - ✗ Only need one solution (use greedy or dynamic programming) - ✗ Can't prune effectively (exponential explosion) - ✗ Problem has optimal substructure (use DP instead) - ✗ Iterative solution is clearer and equally efficient
Backtracking vs Alternatives:
| Approach | When to Use | Example |
|---|---|---|
| Backtracking | All solutions, can prune | Permutations, N-Queens |
| Dynamic Programming | Optimal solution, overlapping subproblems | Fibonacci, Knapsack |
| Greedy | Optimal solution, greedy choice property | Dijkstra, Huffman coding |
| Brute Force | Small input, no pruning possible | Exhaustive search |
Lessons Learned
Key Insights from This Journey:
-
Start with the simplest approach: Our naive recursion worked but revealed inefficiencies
-
Backtracking is depth-first exploration: Build one solution at a time, not all at once
-
Pruning is critical: Check constraints before recursing, not after
-
State management matters: Use mutable state with explicit restore (choose-unchoose)
-
Base case precision: Use exact equality (
==), not approximate (>=) -
Trace execution early: Add print statements to visualize the recursion tree
-
Test incrementally: Verify base case, pruning, and state restoration separately
The Backtracking Pattern:
def backtrack_template(input_data):
result = []
current_state = [] # Mutable state
def backtrack(position):
# Base case: complete solution
if is_complete(position):
result.append(make_solution(current_state))
return
# Try each choice at this position
for choice in get_choices(position):
# Prune: skip invalid choices
if not is_valid(choice, current_state):
continue
# CHOOSE: modify state
current_state.append(choice)
# EXPLORE: recurse
backtrack(next_position(position))
# UNCHOOSE: restore state
current_state.pop()
backtrack(start_position)
return result
This pattern applies to countless problems: permutations, combinations, subsets, N-Queens, Sudoku, maze solving, and more. Master this template, and you've mastered backtracking.
Practice Problems
Practice Problems
Apply the backtracking framework to these problems:
Problem 1: Generate All Subsets
Generate all possible subsets of a set of numbers.
Example:
- Input: [1, 2, 3]
- Output: [[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]]
Hint: At each position, you have two choices: include the element or exclude it.
def generate_subsets(nums):
"""
Generate all subsets of nums using backtracking
TODO: Implement this using the backtracking template
"""
result = []
current_subset = []
def backtrack(index):
# Your code here
pass
backtrack(0)
return result
# Test your implementation
# print(generate_subsets([1, 2, 3]))
# Expected: [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
Problem 2: Generate All Permutations
Generate all possible permutations of a list of numbers.
Example:
- Input: [1, 2, 3]
- Output: [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]
Hint: At each position, try each unused number. Track which numbers have been used.
def generate_permutations(nums):
"""
Generate all permutations of nums using backtracking
TODO: Implement this using the backtracking template
Hint: You'll need to track which elements have been used
"""
result = []
current_perm = []
used = [False] * len(nums)
def backtrack():
# Your code here
pass
backtrack()
return result
# Test your implementation
# print(generate_permutations([1, 2, 3]))
# Expected: [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
Problem 3: Combination Sum
Find all unique combinations of numbers that sum to a target.
Example:
- Input: candidates = [2, 3, 6, 7], target = 7
- Output: [[2, 2, 3], [7]]
Constraints: - Numbers can be used multiple times - All numbers are positive - Combinations should be unique
Hint: At each position, try including the current number (and recurse with same index) or skip it (recurse with next index).
def combination_sum(candidates, target):
"""
Find all combinations that sum to target
TODO: Implement this using the backtracking template
Hint: Track the current sum and prune when sum exceeds target
"""
result = []
current_combination = []
def backtrack(index, current_sum):
# Your code here
pass
backtrack(0, 0)
return result
# Test your implementation
# print(combination_sum([2, 3, 6, 7], 7))
# Expected: [[2, 2, 3], [7]]
Problem 4: Valid Parentheses
Generate all combinations of n pairs of valid parentheses.
Example:
- Input: n = 3
- Output: ["((()))", "(()())", "(())()", "()(())", "()()()"]
Constraints: - At any point, number of closing parens ≤ number of opening parens - Total opening parens = total closing parens = n
Hint: Track count of opening and closing parens. Prune when closing > opening.
def generate_parentheses(n):
"""
Generate all valid combinations of n pairs of parentheses
TODO: Implement this using the backtracking template
Hint: Track open_count and close_count, prune when close_count > open_count
"""
result = []
current_string = []
def backtrack(open_count, close_count):
# Your code here
pass
backtrack(0, 0)
return result
# Test your implementation
# print(generate_parentheses(3))
# Expected: ["((()))", "(()())", "(())()", "()(())", "()()()"]
Solutions
Here are the solutions to verify your implementations:
# Solution 1: Generate All Subsets
def generate_subsets_solution(nums):
result = []
current_subset = []
def backtrack(index):
# Base case: processed all elements
if index == len(nums):
result.append(current_subset.copy())
return
# Choice 1: Include nums[index]
current_subset.append(nums[index])
backtrack(index + 1)
current_subset.pop()
# Choice 2: Exclude nums[index]
backtrack(index + 1)
backtrack(0)
return result
print("Solution 1: Subsets")
print(generate_subsets_solution([1, 2, 3]))
# Solution 2: Generate All Permutations
def generate_permutations_solution(nums):
result = []
current_perm = []
used = [False] * len(nums)
def backtrack():
# Base case: used all numbers
if len(current_perm) == len(nums):
result.append(current_perm.copy())
return
# Try each unused number
for i in range(len(nums)):
if used[i]:
continue
# Choose
current_perm.append(nums[i])
used[i] = True
# Explore
backtrack()
# Unchoose
current_perm.pop()
used[i] = False
backtrack()
return result
print("\nSolution 2: Permutations")
print(generate_permutations_solution([1, 2, 3]))
# Solution 3: Combination Sum
def combination_sum_solution(candidates, target):
result = []
current_combination = []
def backtrack(index, current_sum):
# Base case: found valid combination
if current_sum == target:
result.append(current_combination.copy())
return
# Pruning: sum exceeds target
if current_sum > target:
return
# Try each candidate starting from index
for i in range(index, len(candidates)):
# Choose
current_combination.append(candidates[i])
# Explore (same index because we can reuse numbers)
backtrack(i, current_sum + candidates[i])
# Unchoose
current_combination.pop()
backtrack(0, 0)
return result
print("\nSolution 3: Combination Sum")
print(combination_sum_solution([2, 3, 6, 7], 7))
# Solution 4: Valid Parentheses
def generate_parentheses_solution(n):
result = []
current_string = []
def backtrack(open_count, close_count):
# Base case: used all parentheses
if len(current_string) == 2 * n:
result.append(''.join(current_string))
return
# Can add opening paren if we haven't used all n
if open_count < n:
current_string.append('(')
backtrack(open_count + 1, close_count)
current_string.pop()
# Can add closing paren if it won't exceed opening
if close_count < open_count:
current_string.append(')')
backtrack(open_count, close_count + 1)
current_string.pop()
backtrack(0, 0)
return result
print("\nSolution 4: Valid Parentheses")
print(generate_parentheses_solution(3))
Python Output:
Solution 1: Subsets
[[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
Solution 2: Permutations
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
Solution 3: Combination Sum
[[2, 2, 3], [7]]
Solution 4: Valid Parentheses
['((()))', '(()())', '(())()', '()(())', '()()()']
Key Takeaways from Practice Problems
- Subsets: Binary choice at each position (include/exclude)
- Permutations: Try each unused element at each position
- Combination Sum: Can reuse elements, prune when sum exceeds target
- Parentheses: Track state (open/close counts), prune invalid states
All follow the same backtracking template with different: - State representation (subset, permutation, sum, counts) - Choice generation (binary, all unused, all from index, open/close) - Pruning conditions (sum > target, close > open) - Base cases (index == n, length == n, sum == target)
Master these patterns, and you can solve any backtracking problem.